feat(vortex-row): row-oriented byte encoder (size + encode passes)#8253
Conversation
Adds `vortex-row`, which encodes N columnar arrays into a single byte-comparable `ListView<u8>` (the Vortex analogue of arrow-row) for use as sort/row keys. Encoding runs as two scalar functions behind the `RowEncoder` API: a `RowSize` sizing/classification pass and a `RowEncode` pass that allocates one contiguous buffer and writes each column left-to-right into its per-row slot. Per-column ordering (`RowSortField`) controls ascending/ descending and null placement. Includes the byte codec for fixed-width, varlen, and nested canonical types, the `convert_columns`/`compute_row_sizes` helpers, round-trip + invariant tests, and arrow-row-baselined throughput benches. The crate is marked `publish = false` for now, so no public-api.lock is tracked. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Merging this PR will improve performance by 15.37%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | Simulation | baseline_lt[16, 65536] |
219.4 µs | 247 µs | -11.17% |
| ⚡ | Simulation | chunked_bool_canonical_into[(1000, 10)] |
46.6 µs | 31.7 µs | +46.97% |
| ⚡ | Simulation | chunked_varbinview_into_canonical[(1000, 10)] |
213.2 µs | 177.1 µs | +20.39% |
| ⚡ | Simulation | chunked_varbinview_canonical_into[(100, 100)] |
309.6 µs | 274.7 µs | +12.71% |
| 🆕 | Simulation | primitive_i64_arrow_row |
N/A | 2.4 ms | N/A |
| 🆕 | Simulation | primitive_i64_vortex |
N/A | 1.5 ms | N/A |
| 🆕 | Simulation | struct_mixed_arrow_row |
N/A | 18.7 ms | N/A |
| 🆕 | Simulation | struct_mixed_vortex |
N/A | 22.9 ms | N/A |
| 🆕 | Simulation | utf8_arrow_row |
N/A | 8.6 ms | N/A |
| 🆕 | Simulation | utf8_vortex |
N/A | 9.3 ms | N/A |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/nice-archimedes-yjGyO (0892a82) with develop (f127357)
Add a CodSpeed shard for `vortex-row` so the `row_encode` divan benchmarks (vortex vs arrow-row) build and run in CI alongside the other crates. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
The row encoder builds the output `(elements, offsets, sizes)` triple itself, so the invariants `ListViewArray::try_new` validates (monotone offsets, per-row slices within bounds and disjoint) already hold by construction. Skip the revalidation walk via `new_unchecked`. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Introduce `ValidityKind`/`resolve_validity`: resolve a column's validity once, materializing the per-row mask only when the column may actually contain nulls. The size pass for varbinview and the bool and primitive encoders now branch once on validity, so the all-valid path drops the per-row `mask.value(i)` check (and mask allocation) entirely. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Every byte of the output range is written by some encoder: fixed-width null rows write sentinel + explicit zero-fill, varlen encoders zero-pad their final partial block, and struct/FSL null parent bodies are overwritten with the canonical null encoding. The pre-zero-init memset is therefore redundant, so replace it with `set_len`, saving a `total_len`-byte memset per call. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Materialize the listview offsets buffer with `set_len` + a slice write instead of per-row `push`. For the pure-fixed path, `iter_mut().enumerate()` lets LLVM auto-vectorize `offsets[i] = i * fixed_per_row` (no per-element bounds or capacity checks). `nrows` is validated to fit u32 at function entry, so the cast is exact. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Write the mixed (fixed + varlen) offsets through `iter_mut().zip` with wrapping arithmetic, mirroring the pure-fixed path: this elides per-element bounds checks so the `i * fixed_per_row` multiply auto-vectorizes while the varlen prefix sum stays a cheap sequential accumulator. The total is validated to fit u32 upstream, so the wrapping operations never actually wrap. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
…ping The varlen body writer was a per-byte XOR loop. Split it into an ascending fast path (`copy_nonoverlapping` of each 32-byte block plus a single stamped continuation byte, then a partial final block) and a descending path that XORs a u64 at a time via `xor_copy_block` for a vectorizable inner loop. The emitted bytes are identical to the previous implementation for every length and direction (full-block counts and final length byte match exactly); only the write strategy changes. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Replace the `with_iterator` traversal in `encode_varbinview` with a direct walk over the view array: cache the data-buffer slices once, then for each row read the bytes straight from the inlined view slot or the referenced buffer at `offset..offset+len`. This drops the iterator's per-row option/bounds machinery. Validity is resolved once via `resolve_validity`, keeping the no-nulls path branch-free on validity. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
The auto-vectorized offset loops and the varlen block writer used raw `as` casts that trip this crate's `cast_possible_truncation` lint. Iterate a `u32` counter instead of casting `usize` per element, and use `u8`/`u32` `try_from` for the varlen final-block length byte and total byte count. No behavior change. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Classify each column in the size pass (`ColKind` + `first_varlen_idx`): a fixed-width column with no varlen column before it has a constant within-row offset, so its write position is pure arithmetic (`i * fixed_per_row + prefix + var_prefix[i]`) with no per-row cursor. Route those columns through `field_encode_fixed_arithmetic`; the cursor path is seeded to start at the first varlen column. Primitive columns in the pure-fixed case use a `chunks_exact_mut` hot loop (matching arrow-row's not-null path); all other fixed types reuse the cursor encoder at the computed offsets, so output is byte-identical. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Run the vortex-row row_encode benchmarks as part of the existing 'Storage formats' shard rather than adding a dedicated ninth shard. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
81de8fa to
2fc07fa
Compare
FSST is not order-preserving, so row keys must be the decompressed bytes; the only strategy today is decompress to a canonical VarBinView then row-encode it. This bench measures that path and its two phases (decompress-only, and row-encode of an already-decompressed column) on compressible multi-block strings, to quantify the opportunity for a future fused FSST row-encode kernel: the phases are additive (decompress ~46%, row-encode ~54%), and the row-encode phase re-reads/re-writes the decompressed bytes a fused kernel could emit once. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Apply nightly rustfmt formatting to the FSST benchmark added in the previous commit. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Adds `fsst_fast_fused`: bulk-decompresses the FSST code heap straight into a contiguous buffer (no intermediate VarBinViewArray) and block-encodes rows directly into the row-key ListView using the stored uncompressed_lengths (free size pass), with the same no-zero-init / no-extra-copy techniques as the row encoder. Lets us compare the fused path head-to-head against decode-then-convert. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Adds `fsst_fast_scatter`: keeps FSST's fast contiguous bulk decompressor but runs it into a cache-resident scratch one row-batch at a time, scattering each row into block form from cache so the decompressed bytes never round-trip through main memory. A one-time assert_arrays_eq! check confirms it produces byte-identical row keys to the straightforward fused path. Result: fast_scatter is on par with fast_fused (no speedup) — the decompressed buffer is already consumed cache-warm in the simple fused path, so avoiding the round-trip saves nothing; the workload is CPU-bound on FSST symbol decode plus block-copy. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
| fn add_size_const(sizes: &mut [u32], add: u32) { | ||
| for s in sizes.iter_mut() { | ||
| *s += add; | ||
| } | ||
| } | ||
|
|
||
| fn add_size_null(arr: &NullArray, sizes: &mut [u32]) { | ||
| debug_assert_eq!(arr.len(), sizes.len()); | ||
| // Just a sentinel byte per row. | ||
| for s in sizes.iter_mut() { | ||
| *s += 1; | ||
| } | ||
| } | ||
|
|
||
| fn add_size_primitive(arr: &PrimitiveArray, sizes: &mut [u32]) { | ||
| let width = byte_width_u32(arr.ptype().byte_width()); | ||
| add_size_const(sizes, encoded_size_for_fixed(width)); | ||
| } | ||
|
|
||
| fn add_size_decimal(arr: &DecimalArray, sizes: &mut [u32]) { | ||
| let width = byte_width_u32(arr.values_type().byte_width()); | ||
| add_size_const(sizes, encoded_size_for_fixed(width)); | ||
| } |
There was a problem hiding this comment.
all these are just one-liners - sizes.iter_mut().for_each(...)
| let pos = (row_offsets[i] + col_offset[i]) as usize; | ||
| out[pos] = non_null; | ||
| // false=0x01, true=0x02 so false < true; XOR for descending | ||
| let raw = if bits.value(i) { 0x02u8 } else { 0x01u8 }; |
| let pos = (row_offsets[i] + col_offset[i]) as usize; | ||
| if mask.value(i) { | ||
| out[pos] = non_null; | ||
| let raw = if bits.value(i) { 0x02u8 } else { 0x01u8 }; |
| /// Returns the sentinel byte to write for a non-null value. | ||
| #[inline] | ||
| pub(crate) fn non_null_sentinel(&self) -> u8 { | ||
| // Non-null is always 0x01. Null choices are < or > 0x01. | ||
| 0x01 | ||
| } | ||
|
|
||
| /// Returns the sentinel byte to write for a null value. | ||
| #[inline] | ||
| pub(crate) fn null_sentinel(&self) -> u8 { | ||
| if self.nulls_first { | ||
| // Nulls before non-nulls (smaller byte sorts first). | ||
| 0x00 | ||
| } else { | ||
| // Nulls after non-nulls (larger byte sorts later). | ||
| 0x02 | ||
| } | ||
| } |
There was a problem hiding this comment.
These (non_null_sentinel, null_sentinel) shouldn't be part of RowSortField, they don't really fit the rest of the pattern
| } | ||
| } | ||
|
|
||
| const FIELDS_INLINE: usize = 4; |
There was a problem hiding this comment.
explain? not sure I get what's going on here
| /// reverses the encoded value bytes for that column. Null placement is controlled separately, | ||
| /// so nulls keep the requested position relative to non-null values in either direction. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | ||
| pub struct RowSortField { |
| @@ -0,0 +1,615 @@ | |||
| // SPDX-License-Identifier: Apache-2.0 | |||
There was a problem hiding this comment.
I think there are two types of tests that are missing here:
- Lock in some reference values (Like in the docs page).
- some property testing - generate a dtype, generate values for that dtype and assert their order doesn't change.
| @@ -0,0 +1,539 @@ | |||
| # Row Encoding Byte Sort Specification | |||
There was a problem hiding this comment.
can more of this content be in the lib.rs? I think that's a much more accessible location, and more helpful for reading the code.
| @@ -0,0 +1,539 @@ | |||
| # Row Encoding Byte Sort Specification | |||
There was a problem hiding this comment.
How much trust do you have in this scheme?
| @@ -0,0 +1,1257 @@ | |||
| // SPDX-License-Identifier: Apache-2.0 | |||
There was a problem hiding this comment.
This file is almost half this PR, probably worth splitting it into a module with multiple files
| /// Encoders pattern-match on this once before their inner loop so the no-nulls fast path | ||
| /// avoids per-row `mask.value(i)` branches entirely, and the nullable path materializes the | ||
| /// mask exactly once. | ||
| pub(crate) enum ValidityKind { |
There was a problem hiding this comment.
Isn't this just AllOr from vortex-mask?
| 1 + value_bytes | ||
| } | ||
|
|
||
| fn byte_width_u32(width: usize) -> u32 { |
There was a problem hiding this comment.
byte width always fit in a usize, why can't we just cast?
| /// path (no varlen before this column, so the within-row position is constant per row) and | ||
| /// the cursor-write path. | ||
| #[derive(Clone, Copy, Debug)] | ||
| pub(crate) enum ColKind { |
| // Each row has `list_size` fixed-width elements regardless of null parent mask. | ||
| let body = w | ||
| .checked_mul(u32::try_from(list_size).vortex_expect("list_size fits u32")) | ||
| .vortex_expect("FSL body width overflow"); |
| ptype.byte_width(), | ||
| )))), | ||
| DType::Decimal(dt, _) => { | ||
| let vt = DecimalType::smallest_decimal_value_type(dt); |
There was a problem hiding this comment.
is shrinking the decimal here sound? I think it makes the behavior much less predictable.
| match row_width_for_dtype(&field_dtype)? { | ||
| RowWidth::Fixed(w) => { | ||
| total = total.checked_add(w).ok_or_else(|| { | ||
| vortex_error::vortex_err!("Struct row width overflows u32") |
|
This PR is pretty big, I've made an effort but given that its already merged I have other priorities. |
Follow-up to the PR #8253 review pass: - Make the size pass fully fallible: add_size_* now return VortexResult and use checked arithmetic, so an input whose per-row encoding exceeds u32::MAX surfaces a VortexError instead of panicking via vortex_expect. encoded_size_for_non_empty_varlen and encode_non_empty_varlen_body likewise return VortexResult for their byte-total overflow checks. - Drop the #[allow(cast_possible_truncation)] on byte_width_u32; use u32::try_from with an infallible-invariant expect instead of a bare cast. - Add reference_row_bytes_match_spec: encodes the worked-example row from docs/specs/row-encoding.md and asserts the exact encoded bytes, pinning the byte layout and keeping the spec honest. Signed-off-by: Claude <noreply@anthropic.com> https://claude.ai/code/session_019GXtsg21qhpxDVD9ZUpFTx
Summary
Adds
vortex-row, a new crate that encodes one or more columnar Vortex arrays into a singleListView<u8>whose per-row byte slices are lexicographically comparable. The byte ordermatches tuple ordering of the input values under per-column sort options, so the output works
directly as a sort key / row key — the Vortex analogue of
arrow-row.This PR includes the base row-encoding API, the two scalar-function passes, the byte codec,
focused tests, a
row_encodebenchmark, and the row byte-layout documentation. The crate ismarked
publish = false, so nopublic-api.lockis tracked while the API is still settling.FSST-specific row-encoding experiments are intentionally not included in this PR.
Design
Encoding runs as two scalar functions wired behind the
RowEncoderAPI:RowSize. Walks the N input columns once, classifies each column asfixed- or variable-width, accumulates the fixed-width prefix per row, and lazily collects
per-row variable lengths. Returns
Struct { fixed: u32, var: u32 }so callers read per-rowwidths without materializing the constant
fixedslot as a per-row buffer.RowEncode. Uses those sizes to compute totals, allocate one contiguouselements buffer, build per-row absolute offsets, then writes each column left-to-right into
its per-row slot via a write cursor that doubles as the ListView
sizesarray, so noseparate finalize step is needed.
The converter is effectively 2 passes for the pure-fixed-width case and 3 when
variable-length columns require the prefix-sum offsets pass.
Per-column ordering is controlled by
RowSortField { descending, nulls_first }: descendingreverses the encoded value bytes, and leading sentinel bytes place nulls before or after
non-nulls independently of sort direction.
API Layout
convert_columns(cols: &[ArrayRef], fields: &[RowSortField], ctx) -> VortexResult<ListViewArray>is the one-shot entry point;
RowEncoderis the reusable form.RowEncoder,convert_columns(_with_options),compute_row_sizes(_with_options)src/encoder.rsRowEncodescalar fn + encode driversrc/encode.rsRowSizescalar fn + size/classify pass (compute_sizes)src/size.rsRowEncodingOptions,RowSortFieldsrc/options.rsfield_size/field_encode)src/codec.rsinitialize(session)+ re-exportssrc/lib.rsType Coverage
Supported: nulls, booleans, integer/float primitives, decimals up to 128 bits, UTF-8 and
binary, structs, and fixed-size lists.
Rejected: extension types, variant, union, and variable-size list arrays.
Decimal256is alsonot implemented. Temporal extensions could be added later by normalizing them to storage arrays
at the
RowEncoderboundary once the temporal ordering contract is made explicit.Docs
Adds
docs/specs/row-encoding.md, a FoundationDB-tuple-style byte-sort specification with:BE(value)definition and examplesThe spec and
vortex-row/README.mdboth mark the byte layout as experimental.Testing
cargo test -p vortex-row— 23 tests passed.uv run --all-packages make -C docs doctest— 268 doctests passed.cargo +nightly fmt --all— clean.cargo clippy -p vortex-row --all-targets --all-features -- -D warnings— clean.git diff --check— clean.